| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814 |
- 'use client';
- import './style.scss';
- import { use, useCallback, useEffect, useState } from 'react';
- import Link from 'next/link';
- import { useRouter, useSearchParams } from 'next/navigation';
- import { CreditCard, ExternalLink, Gamepad2, Package, ShoppingCart, Ticket } from 'lucide-react';
- import { fetchApi } from '@/lib/utils/client';
- import Loading from '@/app/component/Loading';
- import PayButton, { type PayButtonState } from '@/app/component/PayButton';
- import useAuth from '@/hooks/useAuth';
- import useCart from '@/hooks/useCart';
- import type {
- ProductDetail,
- ProductType,
- ChannelSearchResponse,
- ChannelSearchRow
- } from '@/types/store';
- /** 최근 후원 채널 localStorage MRU. 최신 사용 순(newest first), 최대 5개. */
- const RECENT_KEY = 'antooza:recent-donation-channels';
- const RECENT_MAX = 5;
- function loadRecent(): ChannelSearchRow[]
- {
- if (typeof window === 'undefined') {
- return [];
- }
- try {
- const raw = localStorage.getItem(RECENT_KEY);
- return raw ? JSON.parse(raw) as ChannelSearchRow[] : [];
- }
- catch {
- return [];
- }
- }
- function saveRecent(list: ChannelSearchRow[])
- {
- if (typeof window === 'undefined') {
- return;
- }
- localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_MAX)));
- }
- function addRecent(ch: ChannelSearchRow)
- {
- const cur = loadRecent().filter(c => c.id !== ch.id);
- saveRecent([ch, ...cur]);
- }
- function removeRecent(id: number)
- {
- saveRecent(loadRecent().filter(c => c.id !== id));
- }
- const TYPE_BADGE_CLASS: Record<ProductType, string> = {
- 1: 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300',
- 2: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
- };
- const TYPE_LABEL: Record<ProductType, string> = { 1: '실물', 2: '쿠폰' };
- function TypeBadge({ type, size = 'sm' }: { type: ProductType; size?: 'sm'|'md' })
- {
- const Icon = type === 2 ? Ticket : Package;
- const padCls = size === 'md' ? 'p-1.5' : 'p-1';
- const iconCls = size === 'md' ? 'size-3.5' : 'size-3';
- return (
- <span
- title={TYPE_LABEL[type]}
- aria-label={TYPE_LABEL[type]}
- className={`inline-flex items-center justify-center rounded ${padCls} ${TYPE_BADGE_CLASS[type]}`}
- >
- <Icon className={iconCls} />
- </span>
- );
- }
- const HARD_MAX = 999;
- function formatPrice(n: number): string
- {
- return n.toLocaleString('ko-KR');
- }
- function clampQuantity(value: number, stock: number, minPurchase: number, maxPurchase: number): number
- {
- const stockCap = stock > 0 ? Math.min(stock, HARD_MAX) : HARD_MAX;
- const upper = Math.min(maxPurchase, stockCap);
- return Math.max(minPurchase, Math.min(value, upper));
- }
- export default function StoreDetailPage({ params }: { params: Promise<{ id: string }> })
- {
- const { id } = use(params);
- const productID = parseInt(id, 10);
- const router = useRouter();
- const searchParams = useSearchParams();
- const codeParam = searchParams.get('code');
- const { loginCheck } = useAuth();
- const { addItem, setChannel } = useCart();
- const [product, setProduct] = useState<ProductDetail|null>(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState<string|null>(null);
- const [quantity, setQuantity] = useState(1);
- // 상품 로드 후 초기 수량을 minPurchase로 세팅
- useEffect(() => {
- if (product) {
- setQuantity(product.minPurchase);
- setOptedOut(false); // 상품 전환 시 이전 상품의 선택안함 상태 잔존 방지
- }
- }, [product]);
- const [selectedChannel, setSelectedChannel] = useState<ChannelSearchRow|null>(null);
- const [optedOut, setOptedOut] = useState(false);
- const [channelModalOpen, setChannelModalOpen] = useState(false);
- const [pendingPurchase, setPendingPurchase] = useState(false);
- const [payState] = useState<PayButtonState>('idle');
- const [submitError, setSubmitError] = useState<string|null>(null);
- const [tab, setTab] = useState<'product'|'game'>('product');
- const load = useCallback(async () => {
- setLoading(true);
- const res = await fetchApi<ProductDetail>(`/api/store/products/${productID}`, { silent: true });
- if (res.success && res.data) {
- setProduct(res.data);
- setError(null);
- }
- else {
- setError(res.message || '상품을 불러올 수 없습니다.');
- }
- setLoading(false);
- }, [productID]);
- useEffect(() => {
- load();
- }, [load]);
- // querystring ?code=XXX → 후원 채널 자동 설정 (case-insensitive 검증, 무효 시 조용히 무시)
- useEffect(() => {
- if (!codeParam) {
- return;
- }
- let cancelled = false;
- (async () => {
- const res = await fetchApi<ChannelSearchRow>(`/api/store/channels/by-code?code=${encodeURIComponent(codeParam)}`, { silent: true });
- if (cancelled) {
- return;
- }
- if (res.success && res.data) {
- setSelectedChannel(res.data);
- addRecent(res.data);
- }
- })();
- return () => { cancelled = true; };
- }, [codeParam]);
- const proceedPurchase = (channel: ChannelSearchRow|null) => {
- if (!product) {
- return;
- }
- // 바로 구매: 현재 선택 상품을 cart 에 add 후 /checkout 으로 이동.
- // 기존 cart 항목은 보존되며 함께 결제됨.
- addItem({
- productID: product.id,
- name: product.name,
- thumbnail: product.thumbnail,
- price: product.salePrice,
- type: product.type,
- gameName: product.gameKorName,
- stock: product.stock,
- requireDonationChannel: product.requireDonationChannel,
- quantity
- });
- setChannel(channel);
- router.push('/checkout');
- };
- const handlePurchase = () => {
- if (!loginCheck()) {
- return;
- }
- if (!product) {
- return;
- }
- if (quantity < 1) {
- setSubmitError('수량은 1 이상이어야 합니다.');
- return;
- }
- setSubmitError(null);
- // 후원 채널 확인 절차 강제 — 채널 미선택 시 (필수 상품이거나 선택안함 미체크면) 모달을 먼저 연다.
- if (!selectedChannel && (product.requireDonationChannel || !optedOut)) {
- setPendingPurchase(true);
- setChannelModalOpen(true);
- return;
- }
- proceedPurchase(selectedChannel);
- };
- const handleAddToCart = () => {
- if (!product) {
- return;
- }
- addItem({
- productID: product.id,
- name: product.name,
- thumbnail: product.thumbnail,
- price: product.salePrice,
- type: product.type,
- gameName: product.gameKorName,
- stock: product.stock,
- requireDonationChannel: product.requireDonationChannel,
- quantity
- });
- setSubmitError(null);
- // 작은 피드백 — 차후 toast 시스템으로 교체 가능
- alert('장바구니에 담았습니다.');
- };
- if (loading) {
- return <Loading />;
- }
- if (error || !product) {
- return (
- <div className='container mx-auto px-4 py-12 text-center'>
- <p className='text-red-600 mb-4'>{error || '상품을 찾을 수 없습니다.'}</p>
- <Link href='/store' className='text-blue-600 underline'>상점으로 돌아가기</Link>
- </div>
- );
- }
- const unitPrice = product.salePrice; // 할인 반영된 단가
- const total = unitPrice * quantity;
- const stockLabel = product.stock === -1 ? '' : `재고 ${product.stock}개`;
- const hasDiscount = product.discountType !== 0 && product.salePrice !== product.price;
- return (
- <div id='store-detail' className='container mx-auto max-w-5xl px-4 py-6'>
- {/* breadcrumb — 모두 파랑 계열 */}
- <nav className='text-sm mb-4 flex flex-wrap items-center gap-1 text-blue-600 dark:text-blue-400'>
- <Link href='/store' className='hover:underline'>상점</Link>
- <span className='text-neutral-400 dark:text-neutral-600 text-xl leading-none mx-0.5'>›</span>
- <Link href={`/store?gameID=${product.gameID}`} className='hover:underline'>
- {product.gameKorName}
- </Link>
- <span className='text-neutral-400 dark:text-neutral-600 text-xl leading-none mx-0.5'>›</span>
- <span className='truncate'>{product.name}</span>
- </nav>
- <div className='grid grid-cols-1 md:grid-cols-[360px_1fr] gap-6 lg:gap-10'>
- {/* 좌측 — 상품 이미지 (작게) */}
- <div>
- <div className='aspect-square bg-neutral-100 dark:bg-neutral-800 rounded-lg overflow-hidden flex items-center justify-center border border-neutral-200 dark:border-neutral-800 sticky md:top-4'>
- {product.thumbnail ? (
- // eslint-disable-next-line @next/next/no-img-element
- <img src={product.thumbnail} alt={product.name} className='w-full h-full max-w-[calc(100%/1.6)] max-h-[calc(100%/1.6)]' />
- ) : (
- <span className='text-neutral-400'>이미지 없음</span>
- )}
- </div>
- </div>
- {/* 우측 — 주문 영역 */}
- <div className='min-w-0'>
- <div className='flex items-center gap-2 mb-2'>
- <TypeBadge type={product.type} size='md' />
- <Gamepad2 className='size-4 shrink-0 text-purple-600 dark:text-purple-400' />
- <span className='text-sm font-semibold text-purple-600 dark:text-purple-400'>
- {product.gameKorName}
- </span>
- {product.gameEngName && (
- <span className='text-xs text-neutral-400'>({product.gameEngName})</span>
- )}
- </div>
- <h1 className='text-2xl md:text-3xl font-bold mb-2 break-keep'>{product.name}</h1>
- <div className='text-xs text-neutral-500 mb-4'>
- {product.gameLink ? (
- <a
- href={product.gameLink}
- target='_blank'
- rel='noopener noreferrer'
- className='inline-flex items-center gap-1 hover:underline hover:text-neutral-700 dark:hover:text-neutral-300'
- >
- {product.gamePublisher}
- <ExternalLink className='size-3' aria-hidden />
- </a>
- ) : (
- product.gamePublisher
- )}
- </div>
- <div className='border-t border-b border-neutral-200 dark:border-neutral-800 py-4 mb-4'>
- <div className='text-3xl md:text-4xl font-extrabold text-red-600 dark:text-red-400'>
- {formatPrice(unitPrice)}P
- </div>
- {hasDiscount && (
- <div className='flex items-baseline gap-2 mt-1'>
- <span className='text-sm line-through text-yellow-600 dark:text-yellow-400'>
- {formatPrice(product.price)}P
- </span>
- <span className='text-sm font-semibold text-green-600 dark:text-green-400'>
- {product.discountType === 1 ? `-${product.discountValue}%` : `-${formatPrice(product.discountValue)}P`}
- </span>
- </div>
- )}
- {product.stock !== -1 && (
- <div className='text-xs text-neutral-500 mt-1'>{stockLabel}</div>
- )}
- </div>
- <div className='space-y-4 mb-4'>
- <div className='flex items-center gap-4'>
- <label className='text-sm font-semibold w-20 shrink-0'>수량</label>
- <div className='flex items-stretch border border-neutral-300 dark:border-neutral-700 rounded overflow-hidden'>
- <button
- type='button'
- onClick={() => { setQuantity(q => clampQuantity(q - 1, product.stock, product.minPurchase, product.maxPurchase)); }}
- className='px-3 hover:bg-neutral-100 dark:hover:bg-neutral-800'
- aria-label='수량 감소'
- >
- -
- </button>
- <input
- title={`${quantity}개`}
- type='number'
- min={product.minPurchase}
- max={Math.min(product.maxPurchase, product.stock > 0 ? product.stock : HARD_MAX)}
- value={quantity}
- onChange={(e) => {
- const v = parseInt(e.target.value || '1', 10);
- setQuantity(clampQuantity(isNaN(v) ? product.minPurchase : v, product.stock, product.minPurchase, product.maxPurchase));
- }}
- onBlur={(e) => {
- const v = parseInt(e.target.value || '1', 10);
- setQuantity(clampQuantity(isNaN(v) ? product.minPurchase : v, product.stock, product.minPurchase, product.maxPurchase));
- }}
- className='w-14 text-center bg-white dark:bg-neutral-900 outline-none border-0 appearance-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'
- />
- <button
- type='button'
- onClick={() => { setQuantity(q => clampQuantity(q + 1, product.stock, product.minPurchase, product.maxPurchase)); }}
- className='px-3 hover:bg-neutral-100 dark:hover:bg-neutral-800'
- aria-label='수량 증가'
- >
- +
- </button>
- </div>
- <span className='text-xs text-neutral-500'>{product.minPurchase > 1 ? `최소 ${product.minPurchase}개 / ` : ''}최대 {product.maxPurchase}개</span>
- </div>
- <div className='flex items-center gap-4'>
- <label className='text-sm font-semibold w-20 shrink-0'>후원 채널</label>
- <button
- type='button'
- onClick={() => { if (loginCheck()) { setChannelModalOpen(true); } }}
- className='border border-neutral-300 dark:border-neutral-700 rounded px-3 py-2 text-sm flex-1 text-left bg-white dark:bg-neutral-900 hover:bg-neutral-50 dark:hover:bg-neutral-800 truncate'
- >
- {selectedChannel ? (
- <span className='font-semibold'>{selectedChannel.name}</span>
- ) : (
- <span className='text-neutral-400'>후원 채널 선택 (선택)</span>
- )}
- </button>
- {selectedChannel && (
- <button
- type='button'
- onClick={() => { setSelectedChannel(null); }}
- className='text-xs text-neutral-500 underline'
- >
- 제거
- </button>
- )}
- </div>
- </div>
- <div className='bg-neutral-100 dark:bg-neutral-800 rounded p-4 mb-4'>
- <div className='flex justify-between text-sm mb-2'>
- <span>상품 금액</span>
- <span>{formatPrice(unitPrice)}P × {quantity}</span>
- </div>
- <div className='flex justify-between font-bold text-lg pt-2 border-t border-neutral-300 dark:border-neutral-700'>
- <span>결제 금액</span>
- <span className='text-red-600 dark:text-red-400'>{formatPrice(total)}P</span>
- </div>
- </div>
- {submitError && (
- <div className='text-red-600 text-sm mb-2'>{submitError}</div>
- )}
- <div className='grid grid-cols-1 sm:grid-cols-[1fr_180px] gap-2 mb-3'>
- <PayButton
- state={payState}
- label={product.stock === 0 ? '품절' : '구매하기'}
- icon={product.stock === 0 ? null : <CreditCard className='size-5' aria-hidden />}
- disabled={product.stock === 0}
- onClick={handlePurchase}
- />
- <button
- type='button'
- disabled={product.stock === 0}
- onClick={handleAddToCart}
- className='h-[52px] rounded-md border-2 border-yellow-500 text-yellow-700 dark:border-purple-500 dark:text-purple-300 font-bold hover:bg-yellow-50 dark:hover:bg-purple-950/30 disabled:opacity-50 inline-flex items-center justify-center gap-2'
- >
- <ShoppingCart className='size-5' aria-hidden />
- 장바구니
- </button>
- </div>
- <p className='text-xs text-neutral-500'>
- * 결제 시 캐시 잔액에서 차감됩니다. (토큰 사용 불가)
- </p>
- </div>
- </div>
- {/* 하단 탭 — 상품설명 / 게임소개 */}
- <div className='mt-12'>
- <div className='border-b border-neutral-200 dark:border-neutral-800 flex gap-1'>
- <button
- type='button'
- onClick={() => { setTab('product'); }}
- className={`px-4 py-2 text-sm font-semibold border-b-2 -mb-px ${
- tab === 'product'
- ? 'border-blue-600 text-blue-600 dark:text-blue-400 dark:border-blue-400'
- : 'border-transparent text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
- }`}
- >
- 상품 설명
- </button>
- <button
- type='button'
- onClick={() => { setTab('game'); }}
- className={`px-4 py-2 text-sm font-semibold border-b-2 -mb-px ${
- tab === 'game'
- ? 'border-blue-600 text-blue-600 dark:text-blue-400 dark:border-blue-400'
- : 'border-transparent text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
- }`}
- >
- 게임 소개
- </button>
- </div>
- <div className='py-6'>
- {tab === 'product' && (
- product.description ? (
- <div
- className='product-description ck-content'
- dangerouslySetInnerHTML={{ __html: product.description }}
- />
- ) : (
- <p className='text-neutral-500 text-sm'>상품 설명이 없습니다.</p>
- )
- )}
- {tab === 'game' && (
- <div>
- {product.gameBigImage && (
- <div className='mb-4 rounded-lg overflow-hidden'>
- {/* eslint-disable-next-line @next/next/no-img-element */}
- <img src={product.gameBigImage} alt={product.gameKorName} className='w-full h-auto' />
- </div>
- )}
- <h3 className='text-xl font-bold mb-2'>
- {product.gameKorName}
- {product.gameEngName && (
- <span className='text-neutral-400 text-base ml-2'>({product.gameEngName})</span>
- )}
- </h3>
- <div className='text-sm text-neutral-500 mb-4'>{product.gamePublisher}</div>
- {product.gameDescription ? (
- <div
- className='game-description ck-content'
- dangerouslySetInnerHTML={{ __html: product.gameDescription }}
- />
- ) : (
- <p className='text-neutral-500 text-sm'>게임 소개가 없습니다.</p>
- )}
- {product.gameLink && (
- <a
- href={product.gameLink}
- target='_blank'
- rel='noopener noreferrer'
- className='inline-block mt-4 text-blue-600 dark:text-blue-400 hover:underline text-sm'
- >
- 공식 사이트 바로가기 →
- </a>
- )}
- </div>
- )}
- </div>
- </div>
- {/* 같은 게임 다른 상품 */}
- {product.relatedProducts.length > 0 && (
- <div className='mt-12 border-t border-neutral-200 dark:border-neutral-800 pt-8'>
- <h2 className='text-xl font-bold mb-4'>같은 게임의 다른 상품</h2>
- <div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4'>
- {product.relatedProducts.map((r) => (
- <Link
- key={r.id}
- href={`/store/${r.id}`}
- className='group block rounded-lg border border-neutral-200 dark:border-neutral-800 overflow-hidden hover:shadow-lg transition-shadow bg-white dark:bg-neutral-900'
- >
- <div className='aspect-square bg-neutral-100 dark:bg-neutral-800 overflow-hidden flex items-center justify-center'>
- {r.thumbnail ? (
- // eslint-disable-next-line @next/next/no-img-element
- <img src={r.thumbnail} alt={r.name} className='w-full h-full object-scale-down max-w-[calc(100%/1.6)] max-h-[calc(100%/1.6)] group-hover:scale-105 transition-transform duration-300' />
- ) : (
- <span className='text-neutral-400 text-xs'>이미지 없음</span>
- )}
- </div>
- <div className='p-3'>
- <div className='flex items-center gap-1.5 mb-1.5'>
- <TypeBadge type={r.type} />
- </div>
- <div className='text-sm font-semibold mb-1.5 line-clamp-2 min-h-[2.5rem]'>{r.name}</div>
- <div className='text-lg font-extrabold text-red-600 dark:text-red-400'>{r.discountType !== 0 && r.salePrice !== r.price ? formatPrice(r.salePrice) : formatPrice(r.price)}P{r.discountType !== 0 && r.salePrice !== r.price ? <span className='ml-1.5 text-xs line-through text-yellow-600 dark:text-yellow-400 font-normal'>{formatPrice(r.price)}P</span> : null}</div>
- </div>
- </Link>
- ))}
- </div>
- </div>
- )}
- {channelModalOpen && (
- <ChannelSelectModal
- requireChannel={product.requireDonationChannel}
- onClose={() => { setChannelModalOpen(false); setPendingPurchase(false); }}
- onSelect={(ch) => {
- setSelectedChannel(ch);
- setOptedOut(false);
- addRecent(ch);
- setChannelModalOpen(false);
- if (pendingPurchase) {
- setPendingPurchase(false);
- proceedPurchase(ch);
- }
- }}
- onSkip={() => {
- setSelectedChannel(null);
- setOptedOut(true);
- setChannelModalOpen(false);
- if (pendingPurchase) {
- setPendingPurchase(false);
- proceedPurchase(null);
- }
- }}
- />
- )}
- </div>
- );
- }
- function ChannelSelectModal({ onClose, onSelect, onSkip, requireChannel }: {
- onClose: () => void;
- onSelect: (ch: ChannelSearchRow) => void;
- onSkip: () => void;
- requireChannel: boolean;
- }) {
- const [keyword, setKeyword] = useState('');
- const [recent, setRecent] = useState<ChannelSearchRow[]>([]);
- const [randoms, setRandoms] = useState<ChannelSearchRow[]>([]);
- const [searchResults, setSearchResults] = useState<ChannelSearchRow[]>([]);
- const [loading, setLoading] = useState(true);
- const [initialized, setInitialized] = useState(false);
- // 모달 mount: localStorage 의 최근 채널을 서버에 검증(stale 자동 제거) 후 표시
- useEffect(() => {
- let cancelled = false;
- (async () => {
- const cached = loadRecent();
- let validated: ChannelSearchRow[] = [];
- if (cached.length > 0) {
- const ids = cached.map(c => c.id).join(',');
- const res = await fetchApi<{ list: ChannelSearchRow[] }>(`/api/store/channels/by-ids?ids=${ids}`, { silent: true });
- if (cancelled) {
- return;
- }
- if (res.success && res.data) {
- // 서버 응답에 포함된 ID 만 유지하고 캐시 순서(최신순) 보존. 서버 최신 정보로 덮어쓰기.
- const fresh = new Map(res.data.list.map(c => [c.id, c]));
- validated = cached.flatMap(c => {
- const f = fresh.get(c.id);
- return f ? [f] : [];
- });
- }
- // 검증 결과로 localStorage 동기화 (stale 제거 + 최신 정보 반영)
- saveRecent(validated);
- }
- if (!cancelled) {
- setRecent(validated);
- setInitialized(true);
- }
- })();
- return () => { cancelled = true; };
- }, []);
- // 검색어 변화에 따른 fetch (debounce 300ms). 검색어 없을 때는 random 으로 부족분 보충.
- useEffect(() => {
- if (!initialized) {
- return;
- }
- const handle = setTimeout(async () => {
- setLoading(true);
- // 검색어 있을 때: 검색 API 만 호출. recent 무시.
- if (keyword.trim()) {
- const params = new URLSearchParams();
- params.set('keyword', keyword.trim());
- params.set('page', '1');
- params.set('perPage', '5');
- const res = await fetchApi<ChannelSearchResponse>(`/api/store/channels/search?${params.toString()}`, { silent: true });
- if (res.success && res.data) {
- setSearchResults(res.data.list);
- }
- else {
- setSearchResults([]);
- }
- setLoading(false);
- return;
- }
- // 검색어 없을 때: recent 5개면 random 호출 안 함. 부족 시 (RECENT_MAX - recent.length) 만큼 random 으로 보충.
- const need = RECENT_MAX - recent.length;
- if (need <= 0) {
- setRandoms([]);
- setLoading(false);
- return;
- }
- const params = new URLSearchParams();
- params.set('random', 'true');
- params.set('page', '1');
- // recent 와 중복 가능성 대비해 여유 있게 가져와서 중복 제거
- params.set('perPage', String(need + recent.length));
- const res = await fetchApi<ChannelSearchResponse>(`/api/store/channels/search?${params.toString()}`, { silent: true });
- if (res.success && res.data) {
- const recentIDs = new Set(recent.map(r => r.id));
- const filtered = res.data.list.filter(c => !recentIDs.has(c.id)).slice(0, need);
- setRandoms(filtered);
- }
- else {
- setRandoms([]);
- }
- setLoading(false);
- }, 300);
- return () => { clearTimeout(handle); };
- }, [keyword, initialized, recent]);
- const handleRemoveRecent = (id: number) => {
- removeRecent(id);
- setRecent(prev => prev.filter(c => c.id !== id));
- };
- // 표시할 리스트: 검색어 있으면 검색 결과만, 없으면 recent + randoms.
- const displayList: { row: ChannelSearchRow; isRecent: boolean }[] = keyword.trim()
- ? searchResults.map(row => ({ row, isRecent: false }))
- : [
- ...recent.map(row => ({ row, isRecent: true })),
- ...randoms.map(row => ({ row, isRecent: false }))
- ];
- return (
- <div
- className='fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4'
- onClick={onClose}
- >
- <div
- className='bg-white dark:bg-neutral-900 rounded-lg w-full max-w-md max-h-[80vh] flex flex-col'
- onClick={(e) => { e.stopPropagation(); }}
- >
- <div className='p-4 border-b border-neutral-200 dark:border-neutral-800'>
- <div className='flex justify-between items-center mb-3'>
- <h2 className='font-bold'>후원 채널 선택</h2>
- <button type='button' onClick={onClose} className='text-neutral-500 hover:text-neutral-700'>✕</button>
- </div>
- <input
- type='search'
- value={keyword}
- onChange={(e) => { setKeyword(e.target.value); }}
- placeholder='채널명, 핸들, 후원 코드 검색'
- className='w-full border border-neutral-300 dark:border-neutral-700 rounded px-3 py-2 text-sm bg-white dark:bg-neutral-900'
- maxLength={100}
- />
- {!keyword.trim() ? (
- <p className='text-[11px] text-neutral-400 mt-1'>* 최근 후원 채널 후 무작위 순서로 표시됩니다.</p>
- ) : (
- <p className='text-[11px] text-neutral-400 mt-1'>* 후원 코드는 정확히 입력해야 합니다.</p>
- )}
- </div>
- <div className='flex-1 overflow-y-auto p-2'>
- {loading ? (
- <Loading type={2} />
- ) : displayList.length === 0 ? (
- <div className='text-center py-8 text-neutral-500 text-sm'>채널이 없습니다.</div>
- ) : (
- displayList.map(({ row: ch, isRecent }) => (
- <div
- key={ch.id}
- className='w-full flex items-center gap-3 p-3 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded'
- >
- <button
- type='button'
- onClick={() => { onSelect(ch); }}
- className='flex-1 min-w-0 text-left flex items-center gap-3'
- >
- {ch.thumbnailUrl ? (
- // eslint-disable-next-line @next/next/no-img-element
- <img src={ch.thumbnailUrl} alt='' className='w-10 h-10 rounded-full object-cover shrink-0' />
- ) : (
- <div className='w-10 h-10 rounded-full bg-neutral-300 dark:bg-neutral-700 shrink-0' />
- )}
- <div className='flex-1 min-w-0'>
- <div className='font-semibold truncate flex items-center gap-1.5'>
- <span className='truncate'>{ch.name}</span>
- {ch.isVerified && (
- <span className='shrink-0 text-[10px] bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 rounded px-1'>✓</span>
- )}
- {ch.donationCode && (
- <span
- className='shrink-0 font-mono text-[11px] px-2 py-[3px] rounded-md tracking-[0.15em] uppercase
- bg-neutral-100 dark:bg-neutral-800
- text-neutral-700 dark:text-neutral-200
- border border-neutral-200/60 dark:border-neutral-700/60
- shadow-[inset_0_1px_2px_rgba(0,0,0,0.18),inset_0_-1px_0_rgba(255,255,255,0.55)]
- dark:shadow-[inset_0_1px_3px_rgba(0,0,0,0.6),inset_0_-1px_0_rgba(255,255,255,0.04)]'
- >
- {ch.donationCode}
- </span>
- )}
- </div>
- {ch.handle && (
- <div className='text-xs text-neutral-500 truncate'>{ch.handle}</div>
- )}
- </div>
- </button>
- {isRecent && (
- <button
- type='button'
- onClick={(e) => {
- e.stopPropagation();
- handleRemoveRecent(ch.id);
- }}
- className='shrink-0 size-6 inline-flex items-center justify-center rounded-full
- text-neutral-400 hover:text-neutral-700 hover:bg-neutral-200
- dark:hover:text-neutral-200 dark:hover:bg-neutral-700'
- aria-label='최근 목록에서 제거'
- title='최근 목록에서 제거'
- >
- ✕
- </button>
- )}
- </div>
- ))
- )}
- </div>
- {requireChannel ? (
- <div className='p-3 border-t border-neutral-200 dark:border-neutral-800 text-xs text-amber-600 dark:text-amber-400'>
- 후원 채널 선택이 필수인 상품입니다.
- </div>
- ) : (
- <div className='p-3 border-t border-neutral-200 dark:border-neutral-800'>
- <label className='flex items-center gap-2 text-sm cursor-pointer text-neutral-600 dark:text-neutral-300'>
- <input type='checkbox' onChange={(e) => { if (e.target.checked) { onSkip(); } }} className='size-4' />
- <span>선택 안함</span>
- </label>
- </div>
- )}
- </div>
- </div>
- );
- }
|